import { strict as assert } from "assert";
import * as path from "path";
import { SinonStub } from "sinon";
import * as vs from "vscode";
import { URI } from "vscode-uri";
import { DebuggerType, VmServiceExtension } from "../../../shared/enums";
import { TestDoneNotification, TestStartNotification } from "../../../shared/test_protocol";
import { fsPath } from "../../../shared/utils/fs";
import { waitFor } from "../../../shared/utils/promises";
import { getLaunchConfig } from "../../../shared/utils/test";
import { DartFileCoverage } from "../../../shared/vscode/coverage";
import { DartDebugClient } from "../../dart_debug_client";
import { createDebugClient, killFlutterTester, startDebugger, waitAllThrowIfTerminates } from "../../debug_helpers";
import { activateWithoutAnalysis, captureDebugSessionCustomEvents, checkTreeNodeResults, customScriptExt, deferUntilLast, delay, ensureHasRunWithArgsStarting, fakeCancellationToken, flutterHelloWorldCounterAppFile, flutterHelloWorldExamplePrinterFile, flutterHelloWorldExampleTestFile, flutterHelloWorldFolder, flutterHelloWorldMainFile, flutterHelloWorldPrinterFile, flutterIntegrationTestFile, flutterTestAnotherFile, flutterTestBrokenFile, flutterTestDriverAppFile, flutterTestDriverTestFile, flutterTestMainFile, flutterTestOtherFile, flutterTestSelective1File, flutterTestSelective2File, getCodeLens, getExpectedResults, isTestDoneSuccessNotification, makeTestTextTree, openFile, positionOf, prepareHasRunFile, privateApi, sb, setConfigForTest, waitForResult, watchPromise } from "../../helpers";

describe("flutter test debugger", () => {
	beforeEach("activate flutterTestMainFile", () => activateWithoutAnalysis(flutterTestMainFile));

	beforeEach(() => {
		deferUntilLast("Kill flutter_tester", () => watchPromise("Killing flutter_tester processes", killFlutterTester()));
	});

	let dc: DartDebugClient;
	let consoleOutputCategory: string;
	beforeEach("create debug client", () => {
		dc = createDebugClient(DebuggerType.FlutterTest);
		consoleOutputCategory = dc.isDartDap ? "console" : "stdout";
	});

	for (const runByLine of [false, true]) {
		describe(`when running tests by ${runByLine ? "line" : "name"}`, () => {
			beforeEach("set config.testInvocationMode", async () => {
				await setConfigForTest("dart", "testInvocationMode", runByLine ? "line" : "name");
			});

			it("runs a Flutter test script to completion", async () => {
				const config = await startDebugger(dc, flutterTestMainFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);
			});

			it("can run tests from codelens", async function () {
				const editor = await openFile(flutterTestMainFile);
				await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestMainFile), "Outline for main file");

				const fileCodeLens = await getCodeLens(editor.document);
				const testPos = positionOf(`test^Widgets('Hello world test`);

				const codeLensForTest = fileCodeLens.filter((cl) => cl.range.start.line === testPos.line);
				assert.equal(codeLensForTest.length, 2);

				// If there's no command, skip the test. This happens very infrequently and appears to be a VS Code
				// race condition. Rather than failing our test runs, skip.
				// TODO: Remove this if https://github.com/microsoft/vscode/issues/79805 gets a reliable fix.
				if (!codeLensForTest[0].command)
					this.skip();

				const runAction = codeLensForTest.find((cl) => cl.command!.title === "Run")!;
				assert.equal(runAction.command!.command, "_dart.startWithoutDebuggingTestFromOutline");
				assert.equal(runAction.command!.arguments![0].fullName, "Hello world test");
				assert.equal(runAction.command!.arguments![0].isGroup, false);

				const customEvents = await captureDebugSessionCustomEvents(async () => {
					const didStart = await vs.commands.executeCommand(runAction.command!.command, ...(runAction.command!.arguments ?? [])); // eslint-disable-line @typescript-eslint/no-unsafe-argument
					assert.ok(didStart);
				});

				// Ensure we got at least a "testDone" notification so we know the test run started correctly.
				const testDoneNotification = customEvents.find(isTestDoneSuccessNotification);
				assert.ok(testDoneNotification, JSON.stringify(customEvents.map((e: any) => e.body as unknown), undefined, 4));
			});

			it("can run test with multiline name from codelens", async function () {
				const editor = await openFile(flutterTestMainFile);
				await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestMainFile), "Outline for main file");

				const fileCodeLens = await getCodeLens(editor.document);
				const testPos = positionOf(`test^Widgets('''multi`);

				const codeLensForTest = fileCodeLens.filter((cl) => cl.range.start.line === testPos.line);
				assert.equal(codeLensForTest.length, 2);

				// If there's no command, skip the test. This happens very infrequently and appears to be a VS Code
				// race condition. Rather than failing our test runs, skip.
				// TODO: Remove this if https://github.com/microsoft/vscode/issues/79805 gets a reliable fix.
				if (!codeLensForTest[0].command)
					this.skip();

				const runAction = codeLensForTest.find((cl) => cl.command!.title === "Run")!;
				assert.equal(runAction.command!.command, "_dart.startWithoutDebuggingTestFromOutline");
				assert.equal(runAction.command!.arguments![0].fullName, "multi\nline\ntest");
				assert.equal(runAction.command!.arguments![0].isGroup, false);

				const customEvents = await captureDebugSessionCustomEvents(async () => {
					const didStart = await vs.commands.executeCommand(runAction.command!.command, ...(runAction.command!.arguments ?? [])); // eslint-disable-line @typescript-eslint/no-unsafe-argument
					assert.ok(didStart);
				});

				// Ensure we got at least a "testDone" notification so we know the test run started correctly.
				const testDoneNotification = customEvents.find(isTestDoneSuccessNotification);
				assert.ok(testDoneNotification, JSON.stringify(customEvents.map((e: any) => e.body as unknown), undefined, 4));
			});

			it("can run skipped tests from codelens", async function () {
				const editor = await openFile(flutterTestMainFile);
				await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestMainFile), "Outline for main file");

				const fileCodeLens = await getCodeLens(editor.document);
				const testPos = positionOf(`test^Widgets('Skipped test`);

				const codeLensForTest = fileCodeLens.filter((cl) => cl.range.start.line === testPos.line);
				assert.equal(codeLensForTest.length, 2);

				// If there's no command, skip the test. This happens very infrequently and appears to be a VS Code
				// race condition. Rather than failing our test runs, skip.
				// TODO: Remove this if https://github.com/microsoft/vscode/issues/79805 gets a reliable fix.
				if (!codeLensForTest[0].command)
					this.skip();

				const runAction = codeLensForTest.find((cl) => cl.command!.title === "Run")!;
				assert.equal(runAction.command!.command, "_dart.startWithoutDebuggingTestFromOutline");
				assert.equal(runAction.command!.arguments![0].fullName, "Skipped test");
				assert.equal(runAction.command!.arguments![0].isGroup, false);

				const customEvents = await captureDebugSessionCustomEvents(async () => {
					const didStart = await vs.commands.executeCommand(runAction.command!.command, ...(runAction.command!.arguments ?? [])); // eslint-disable-line @typescript-eslint/no-unsafe-argument
					assert.ok(didStart);
				});

				// Ensure we got at least a "testDone" notification so we know the test run started correctly.
				const testDoneNotification = customEvents.find(isTestDoneSuccessNotification);
				assert.ok(testDoneNotification, JSON.stringify(customEvents.map((e: any) => e.body as unknown), undefined, 4));

				const testDone = testDoneNotification.body as TestDoneNotification;
				assert.equal(testDone.skipped, false); // Test should have run.
			});

			it("receives the expected events from a Flutter test script", async () => {
				const config = await startDebugger(dc, flutterTestMainFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertOutputContains(consoleOutputCategory, `✓ Hello world test`),
					dc.waitForEvent("terminated"),
					dc.assertPassingTest(`Hello world test`),
					dc.launch(config),
				);
			});

			it("successfully runs a Flutter test script with a relative path", async () => {
				const config = await startDebugger(dc, flutterTestMainFile);
				config.program = path.relative(fsPath(flutterHelloWorldFolder), fsPath(flutterTestMainFile));
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertOutputContains(consoleOutputCategory, `✓ Hello world test`),
					dc.assertPassingTest(`Hello world test`),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);
			});

			it("runs the provided script regardless of what's open", async () => {
				await openFile(flutterTestMainFile);
				const config = await startDebugger(dc, flutterTestOtherFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertOutputContains(consoleOutputCategory, `✓ Other tests group Other test\n`),
					dc.assertPassingTest(`Other tests group Other test`),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);
			});

			it("runs the open script if no file is provided", async () => {
				await openFile(flutterTestOtherFile);
				const config = await startDebugger(dc, undefined);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertOutputContains(consoleOutputCategory, `✓ Other tests group Other test\n`),
					dc.assertPassingTest(`Other tests group Other test`),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);
			});

			it("runs all tests if given a folder", async () => {
				const config = await startDebugger(dc, "./test/");
				config.noDebug = true;
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);

				const testFiles = [
					flutterTestMainFile,
					flutterTestOtherFile,
					flutterTestAnotherFile,
					flutterTestBrokenFile,
				];

				for (const file of testFiles) {
					await openFile(file);
					const expectedResults = getExpectedResults();
					const actualResults = makeTestTextTree(file).join("\n");

					assert.ok(expectedResults);
					assert.ok(actualResults);
					checkTreeNodeResults(actualResults, expectedResults);
				}
			});

			it("can run a selection of tests across multiple files", async () => {
				// Discover tests in these files.
				await openFile(flutterTestSelective1File);
				await openFile(flutterTestSelective2File);
				await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestSelective1File));
				await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestSelective2File));
				const controller = privateApi.testController;

				const testItems: vs.TestItem[] = [];

				// Pick multiple tests from multiple files to run.
				const suite1Node = controller.controller.items.get(`SUITE:${fsPath(flutterTestSelective1File)}`)!;
				testItems.push(suite1Node.children.get(`TEST:${fsPath(flutterTestSelective1File)}:pass one`)!);
				testItems.push(suite1Node.children.get(`TEST:${fsPath(flutterTestSelective1File)}:pass two`)!);
				const suite2Node = controller.controller.items.get(`SUITE:${fsPath(flutterTestSelective2File)}`)!;
				testItems.push(suite2Node.children.get(`TEST:${fsPath(flutterTestSelective2File)}:fail one`)!);
				testItems.push(suite2Node.children.get(`TEST:${fsPath(flutterTestSelective2File)}:fail two`)!);
				const testRequest = new vs.TestRunRequest(testItems);

				// Capture all testStart notifications during the debug sessions that are spawned from running these tests.
				const customEvents = await captureDebugSessionCustomEvents(async () => controller.runTests(false, false, testRequest, fakeCancellationToken), true);
				const testEvents = customEvents
					.filter((e) => e.event === "dart.testNotification")
					.filter((e) => e.body.type === "testStart")
					.map((e) => e.body as TestStartNotification)
					.filter((e) => !e.test.name?.startsWith("loading"));
				const testNames = testEvents.map((e) => `${path.basename(fsPath(URI.parse(e.test.root_url ?? e.test.url!)))} / ${e.test.name}`);
				testNames.sort(); // The order is not deterministic.

				// Expect exactly the tests we requested.
				assert.deepStrictEqual(testNames, [
					"selective1_test.dart / pass one",
					"selective1_test.dart / pass two",
					"selective2_test.dart / fail one",
					"selective2_test.dart / fail two",
				]);
			});

			it("runs all tests through Test: Run All Tests", async () => {
				let startedSessions = 0;
				let runningSessions = 0;

				const startSub = vs.debug.onDidStartDebugSession((_s) => {
					startedSessions++;
					runningSessions++;
				});
				const endSub = vs.debug.onDidTerminateDebugSession((_s) => {
					runningSessions--;
				});

				try {
					await captureDebugSessionCustomEvents(async () => vs.commands.executeCommand("testing.runAll"));
					// Allow some time for sessions to start so the startedSessions check doesn't
					// fire immediately after only creating the first session.
					await delay(1000);
					await waitFor(
						() => startedSessions >= 0 && runningSessions === 0,
						50, // check every 50ms
						60000, // wait up to 60 seconds
					);
				} finally {
					startSub.dispose();
					endSub.dispose();
				}

				const testFiles = [
					flutterTestMainFile,
					flutterTestOtherFile,
					flutterTestAnotherFile,
					flutterTestBrokenFile,
					flutterIntegrationTestFile,
				];

				for (const file of testFiles) {
					await openFile(file);
					const expectedResults = getExpectedResults();
					const actualResults = makeTestTextTree(file).join("\n");

					assert.ok(expectedResults);
					assert.ok(actualResults);
					checkTreeNodeResults(actualResults, expectedResults);
				}
			});

			it("can run using a custom tool", async () => {
				const root = fsPath(flutterHelloWorldFolder);
				const hasRunFile = prepareHasRunFile(root, "flutter_test");

				const config = await startDebugger(dc, flutterTestMainFile, {
					customTool: path.join(root, `scripts/custom_flutter_test.${customScriptExt}`),
					customToolReplacesArgs: 0,
				});
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);

				ensureHasRunWithArgsStarting(root, hasRunFile, "test --machine --start-paused");
			});

			it("can replace all args using custom tool", async () => {
				const root = fsPath(flutterHelloWorldFolder);
				const hasRunFile = prepareHasRunFile(root, "flutter_test");

				const config = await startDebugger(dc, flutterTestMainFile, {
					customTool: path.join(root, `scripts/custom_flutter_test.${customScriptExt}`),
					customToolReplacesArgs: 999999,
					// These differ to the usual ones so we can detect they replaced them.
					toolArgs: ["test", "--total-shards", "1", "--shard-index", "0", "--start-paused", "--machine", "-d", "flutter-tester"],
				});
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.waitForEvent("terminated"),
					dc.launch(config),
				);

				ensureHasRunWithArgsStarting(root, hasRunFile, "test --total-shards 1 --shard-index 0 --start-paused --machine -d flutter-tester");
			});

			it("stops at a breakpoint", async () => {
				await openFile(flutterTestMainFile);
				const config = await startDebugger(dc, flutterTestMainFile);
				await dc.hitBreakpoint(config, {
					line: positionOf("^// BREAKPOINT1").line + 1, // positionOf is 0-based, but seems to want 1-based
					path: fsPath(flutterTestMainFile),
				});
			});

			it("stops on exception", async () => {
				await openFile(flutterTestBrokenFile);
				const config = await startDebugger(dc, flutterTestBrokenFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertStoppedLocation("exception", {}),
					dc.launch(config),
				);
			});

			it.skip("stops at the correct location on exception", async () => {
				// TODO: Check the expected location is in the call stack, and that the frames above it are all marked
				// as deemphasized.
				await openFile(flutterTestBrokenFile);
				const config = await startDebugger(dc, flutterTestBrokenFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertStoppedLocation("exception", {
						line: positionOf("^won't find this").line + 1, // positionOf is 0-based, but seems to want 1-based
						path: fsPath(flutterTestBrokenFile),
					}),
					dc.launch(config),
				);
			});

			it("provides exception details when stopped on exception", async () => {
				await openFile(flutterTestBrokenFile);
				const config = await startDebugger(dc, flutterTestBrokenFile);
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertStoppedLocation("exception", {}),
					dc.launch(config),
				);

				const variables = await dc.getTopFrameVariables("Exceptions");
				assert.ok(variables);
				const v = variables.find((v) => v.name === "message");
				assert.ok(v);
				assert.equal(v.evaluateName, "$_threadException.message");
				assert.ok(v.value.startsWith(`"Expected: exactly one matching`));
			});

			it("send failure results for failing tests", async () => {
				await openFile(flutterTestBrokenFile);
				const config = await startDebugger(dc, flutterTestBrokenFile);
				config.noDebug = true;
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertErroringTest(`Hello world test`),
					dc.assertOutput("stderr", "Test failed. See exception logs above.\n"),
					dc.assertOutputContains("stdout", "EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK"),
					dc.launch(config),
				);
			});

			it("can run test_driver tests", async () => {
				// Start the instrumented app.
				const appDc = createDebugClient(DebuggerType.Flutter);
				const appConfig = await startDebugger(appDc, flutterTestDriverAppFile);
				await waitAllThrowIfTerminates(appDc,
					appDc.configurationSequence(),
					appDc.launch(appConfig),
				);

				// Allow some time for the debug service to register its Driver extension so we can find it when
				// looking for the app debug session later.
				await waitFor(
					() => privateApi.debugSessions.find((s) => s.loadedServiceExtensions.includes(VmServiceExtension.Driver)),
					100, // checkEveryMilliseconds
					30000, // tryForMilliseconds
				);

				// Run the integration tests
				const config = await startDebugger(dc, flutterTestDriverTestFile);
				config.noDebug = true;
				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertPassingTest(`Counter App increments the counter`),
					dc.launch(config),
				);
			});

			it("can run integration_test tests", async () => {
				const config = await startDebugger(dc, flutterIntegrationTestFile);
				config.noDebug = true;

				await waitAllThrowIfTerminates(dc,
					dc.configurationSequence(),
					dc.assertPassingTest(`Counter App increments the counter`),
					dc.launch(config),
				);
			});

			it("stops at a breakpoint in test code in integration_test tests", async () => {
				await openFile(flutterIntegrationTestFile);
				const config = await startDebugger(dc, flutterIntegrationTestFile);

				await waitAllThrowIfTerminates(dc,
					dc.hitBreakpoint(config, {
						line: positionOf("^// BREAKPOINT1").line,
						path: fsPath(flutterIntegrationTestFile),
					}),
				);
			});

			it("stops at a breakpoint in app code in integration_test tests", async () => {
				await openFile(flutterHelloWorldCounterAppFile);
				const config = await startDebugger(dc, flutterIntegrationTestFile);

				await waitAllThrowIfTerminates(dc,
					dc.hitBreakpoint(config, {
						line: positionOf("^// BREAKPOINT1").line,
						path: fsPath(flutterHelloWorldCounterAppFile),
					}),
				);
			});
		});
	}

	describe("collects coverage", () => {
		let addCoverageStub: SinonStub | undefined;

		beforeEach(() => {
			const controller = privateApi.testController;
			const createTestRunOriginal = controller.controller.createTestRun.bind(controller.controller);
			const createTestRunStub = sb.stub(controller.controller, "createTestRun");
			createTestRunStub.callsFake((request: vs.TestRunRequest) => {
				const originalResult = createTestRunOriginal(request);
				addCoverageStub = sb.stub(originalResult, "addCoverage").returns(null);
				return originalResult;
			});
		});

		it("for a basic test", async () => {
			// Discover tests.
			await openFile(flutterTestMainFile);
			await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterTestMainFile));
			const controller = privateApi.testController;

			const suiteNode = controller.controller.items.get(`SUITE:${fsPath(flutterTestMainFile)}`)!;
			const testRequest = new vs.TestRunRequest([suiteNode]);

			await controller.runTests(false, true, testRequest, fakeCancellationToken);

			assert(addCoverageStub?.calledOnce);
			const coverage = addCoverageStub.firstCall.args[0] as DartFileCoverage;
			assert.equal(fsPath(coverage.uri), fsPath(flutterHelloWorldMainFile)); // App file, not test file.
			assert.ok(coverage.statementCoverage.covered > 0);
			assert.ok(coverage.statementCoverage.total > 0);
		});

		it("and includes dependencies", async () => {
			// Discover tests.
			await openFile(flutterHelloWorldExampleTestFile);
			await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterHelloWorldExampleTestFile));

			const controller = privateApi.testController;
			const suiteNode = controller.controller.items.get(`SUITE:${fsPath(flutterHelloWorldExampleTestFile)}`)!;
			const testRequest = new vs.TestRunRequest([suiteNode]);

			await controller.runTests(false, true, testRequest, fakeCancellationToken);

			assert(addCoverageStub?.called);
			const coverageFiles = addCoverageStub.getCalls().map((call) => fsPath((call.args[0] as DartFileCoverage).uri));
			assert.deepStrictEqual(coverageFiles, [fsPath(flutterHelloWorldExamplePrinterFile), fsPath(flutterHelloWorldPrinterFile)]);
		});

		it("and excludes configured paths", async () => {
			await setConfigForTest("dart", "coverageExcludePatterns", ["**/example/**"]);

			// Discover tests.
			await openFile(flutterHelloWorldExampleTestFile);
			await waitForResult(() => !!privateApi.fileTracker.getOutlineFor(flutterHelloWorldExampleTestFile));

			const controller = privateApi.testController;
			const suiteNode = controller.controller.items.get(`SUITE:${fsPath(flutterHelloWorldExampleTestFile)}`)!;
			const testRequest = new vs.TestRunRequest([suiteNode]);

			await controller.runTests(false, true, testRequest, fakeCancellationToken);

			assert(addCoverageStub?.called);
			const coverageFiles = addCoverageStub.getCalls().map((call) => fsPath((call.args[0] as DartFileCoverage).uri));
			assert.deepStrictEqual(coverageFiles, [fsPath(flutterHelloWorldPrinterFile)]);
		});

		it("uses correct regex for --coverage-package", () => {
			const config = getLaunchConfig(
				false, // noDebug
				true, // includeCoverage
				true, // isFlutter
				fsPath(flutterHelloWorldExampleTestFile),
				undefined, // testSelection
				false, // shouldRunTestByLine
				undefined, // runSkipped
				undefined, // template
				["flutter_hello_world", "flutter_hello_world_example"], // workspacePackageNames
			);

			const toolArgs = (config as { toolArgs?: string[] }).toolArgs ?? [];
			const coveragePackages: string[] = [];
			for (let i = 0; i < toolArgs.length - 1; i++) {
				if (toolArgs[i] === "--coverage-package")
					coveragePackages.push(toolArgs[i + 1]);
			}

			assert.deepStrictEqual(
				coveragePackages,
				[`^flutter_hello_world$`, `^flutter_hello_world_example$`],
			);
		});
	});
});

